Skip to content

fix(server): prevent unrelated sandbox deletes from blocking deletion events#2340

Open
pimlock wants to merge 5 commits into
mainfrom
2326-unblock-sandbox-delete-events/pimlock
Open

fix(server): prevent unrelated sandbox deletes from blocking deletion events#2340
pimlock wants to merge 5 commits into
mainfrom
2326-unblock-sandbox-delete-events/pimlock

Conversation

@pimlock

@pimlock pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

🏗️ build-from-issue-agent

Summary

Prevent one slow sandbox deletion from blocking unrelated watcher events or deletes. The gateway now serializes duplicate deletes per stable sandbox ID, persists and recovers deletion transitions with version checks, and performs backend calls outside the gateway-wide state guard.

This global locking has been causing E2E tests to flake (see the issue for examples).

Overview of the issue and approach.

Before and after

Before

The delete request held the global synchronization lock while waiting for the compute driver. Watcher events and unrelated deletes could not acquire the same lock and therefore stalled behind the driver call.

flowchart TD
    deleteA["Delete sandbox A"] --> global["Acquire global lock"]
    global --> deleting["Persist phase: Deleting"]
    deleting --> driver["Call compute driver<br/>while holding global lock"]
    driver --> release["Release global lock"]

    watcher["Watcher event"] --> blocked["Wait for global lock"]
    deleteB["Delete sandbox B"] --> blocked
    release --> blocked
Loading

After

The request resolves the sandbox's stable ID, then acquires that ID's gate and the global lock before it starts owned work. Cancellation while either lock is pending leaves no queued worker behind. Once both locks are held, spawning the worker is the commitment point: it persists the transition, releases the global lock, and calls the driver while holding only the per-ID gate. Delete attempts for the same ID never overlap; an active waiter may retry after a failed attempt restores a deletable state.

flowchart TD
    deleteA["Delete sandbox A"] --> idA["Resolve stable ID A"]
    idA --> gateA["Acquire delete gate A<br/>in request future"]
    gateA --> globalA["Acquire global lock<br/>in request future"]
    globalA --> commit["Spawn owned worker<br/>commitment point"]
    commit --> deletingA["CAS phase to Deleting"]
    deletingA --> releaseGlobal["Release global lock"]
    releaseGlobal --> driverA["Call compute driver<br/>while holding only gate A"]
    driverA --> recover["Reacquire global lock if cleanup<br/>or recovery is needed"]
    recover --> releaseGate["Release delete gate A"]

    canceled["Canceled duplicate A"] -. "drops while waiting;<br/>no worker exists" .-> gateA
    watcherAfter["Watcher event"] --> globalOther["Acquire global lock briefly"]
    deleteB["Delete sandbox B"] --> gateB["Acquire delete gate B"]
    gateB --> globalOther
    globalOther --> continue["Continue while driver A is running"]
Loading

Related Issue

Closes #2326

Changes

  • Add weak, process-local per-sandbox delete gates so driver calls for the same ID never overlap without blocking other sandbox IDs; an active waiter may retry after a failed attempt recovers.
  • Resolve the name once in compute and return the stable ID that was actually handled, so telemetry cannot be attributed to a different sandbox after name reuse.
  • Acquire the per-ID gate and initial global guard before spawning cancellation-resistant work, so cancellation while queued disarms the request.
  • Make the delete-gate -> global-lock order explicit: delete-path global-lock acquisitions require a SandboxDeleteGuard proving that the ID-scoped gate is held.
  • Make watcher reconciliation deletion-aware and ignore unknown backend snapshots while logging the dropped event.
  • Recover failed driver deletes from an observed backend snapshot, remove an absent backend row, or roll back the exact deleting version when the backend state is inconclusive; retry transient recovery writes only while that exact version remains current.
  • Delete sandbox rows by stable ID and resource version while retaining the existing best-effort name-based settings cleanup.
  • Extend concurrency, cancellation, name-reuse, telemetry, cleanup, and recovery regression coverage; bound lifecycle E2E commands by the polling deadline.
  • Document the stable deletion invariants, cancellation commitment point, process-local gate scope, serialized retry behavior, and current gateway-restart limitation.

The delete guard makes the intended lock order visible and harder to violate within the delete workflow, but it does not prevent a future caller from acquiring the global lock before requesting a delete gate. Enforcing that globally would require ranked-lock tracking or a broader refactor that restricts all direct lock acquisition, which is outside this PR's scope.

Deviations from Plan

Settings cleanup intentionally retains its existing best-effort name-based behavior. An earlier atomic persistence extension was removed to keep this PR scoped to the deletion concurrency issue.

Testing

  • mise run pre-commit passes
  • Unit tests added/updated
  • E2E tests added/updated (execution delegated to the labeled Branch E2E Checks workflow)

Tests added or updated:

  • Compute: 27 regression tests covering lock isolation, active and canceled waiters, duplicate deletes, name reuse, watcher races, recovery, and cleanup.
  • Handler: name reuse preserves the replacement row and ends telemetry for the stable ID actually handled.
  • E2E: 2 sandbox lifecycle cases use deadline-based polling, and each list command is bounded by the same deadline.

Checklist

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock pimlock added the test:e2e Requires end-to-end coverage label Jul 17, 2026
@copy-pr-bot

copy-pr-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@github-actions

Copy link
Copy Markdown

Label test:e2e applied, but pull-request/2340 is at {"messa while the PR head is 0f18d50. A maintainer needs to comment /ok to test 0f18d50c78cdb7ae46861508c6641342028ad665 to refresh the mirror. Once the mirror catches up, re-run Branch E2E Checks from the Actions tab.

@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 0f18d50

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 8283eec

self.cleanup_sandbox_owned_records(&sandbox).await;
self.sandbox_index.update_from_sandbox(&transition.deleting);
self.sandbox_watch_bus.notify(&candidate_id);
drop(guard);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does returning before we call drop(guard) mean that we run the risk of keeping the lock? (Does Rust have an equivalent to a defer drop(guard)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, the guard is dropped explicitly, so it's released before the function returns. Without that, it happens automatically when the value goes out of scope (e.g. function returns). They refer to it as RAII.
Btw using it after drop would fail the compilation, as drop takes the ownership.

I don't know how common this approach of dropping explicitly is, alternatively the part that needs the lock could be a separate function and then the explicit drop wouldn't be needed.

This was first pass the agent took (after iterating on the plan), so it may need some tweaking. One thing I'd like to have is that the sandbox delete lock cannot be acquired if the global lock is acquired, otherwise deadlocks could happen. I think there should be a way of representing it with the type system to make that enforced, but need to spend some more time on this.

.ok_or_else(|| Status::not_found("sandbox not found"))?;
let candidate_id = candidate.object_id().to_string();
let delete_gate = self.delete_gates.gate_for(&candidate_id);
let _delete_guard = delete_gate.lock().await;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where do we drop this again?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dropped when the variable goes out of scope, so right before this function returns.

Comment on lines +3872 to +3873
let delete_gate = runtime.delete_gates.gate_for(original.object_id());
let delete_guard = delete_gate.lock().await;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under which condition is it required to take a lock? Some code (possibly only tests) call gate_for without a subsequent .lock().await.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock ensures only one delete operation is happening at the same time for a given sandbox. Having multiple run at the same time introduces issues related to how the cleanup is performed and could cause inconsistent state in DB.

Before this change, we'd acquire the global lock for the whole deletion process, so the limitation was that only one sandbox operation (deletion or otherwise) could be happening at the same time. That included invoking driver.delete, which may be slow (waiting for the process to respond to sigterm/sigkill).

This applies the global lock more narrowly and uses local lock for the deletion process (the deletions of unrelated sandbox are independent).

This was the simplest solution we found with the agent, it's not bullet proof, of course it works only for a single gateway, if the mechanism relied on DB for leasing the deletion process, that would work if we have multiple gateways, but that seemed too complex for this fix. But it maybe worth exploring other options.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

/ok to test 6a15fd9

@pimlock
pimlock marked this pull request as ready for review July 17, 2026 18:14
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

gator-agent

PR Review Status

Validation: This PR is project-valid because it implements the maintainer-authored, agent-ready gateway/compute fix in #2326 and stays within that accepted deletion-concurrency scope.

Head SHA: 6a15fd94590d705bfe40773334640046a28050d4

Review findings:

  • Blocking: delete_sandbox spawns the owned worker before resolving the requested name to a stable sandbox ID (crates/openshell-server/src/compute/mod.rs:677-703). If cancellation or scheduling delay overlaps deletion and name reuse, the worker can bind to and delete the replacement sandbox. Resolve the ID before spawning and pass that identity into the worker.
  • Blocking: the per-ID mutex serializes duplicate requests but does not share their result. If the leader's driver call fails and recovery restores Ready, a follower already queued on the gate can acquire it and issue a second driver delete. Use a single-flight operation/result per ID and add deterministic failure/rollback coverage.
  • Non-blocking documentation follow-up: state explicitly that the deletion gate registry is process-local; it does not coordinate HA replicas.

Thanks @elezar — I checked the lock-lifetime and acquisition-order concerns you raised. The current head's SandboxDeleteGuard makes the intended delete-gate → global-lock order explicit, and the RAII behavior @pimlock described is sound. The remaining blockers are the pre-spawn identity binding and failed-flight coalescing cases above.

Docs: architecture/compute-runtimes.md covers the internal deletion invariants. No Fern docs are required because this does not change a direct user-facing command, configuration, or workflow contract.

Checks: required Branch Checks, Helm Lint, and E2E are currently green, but review findings still require an author update.

Next state: gator:in-review

@pimlock pimlock added the gator:in-review Gator is reviewing or awaiting PR review feedback label Jul 17, 2026
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

🏗️ build-from-issue-agent

Review follow-up

Addressed the pre-spawn identity-binding finding in 335961d85d62374664240225bbe8cbea831434bc. delete_sandbox now resolves the requested name to a stable ID before spawning the cancellation-resistant worker, and the worker receives that identity directly. If the original row disappears while the worker is delayed, it acknowledges only that stable ID and cannot target a replacement using the same name.

The failed-flight behavior is intentionally serialized retry rather than result-sharing single-flight:

  • The per-ID gate guarantees that driver delete calls for one sandbox never overlap.
  • If the first attempt succeeds or leaves the row Deleting, the waiter does not issue another driver call.
  • If the first attempt fails and recovery restores Ready, the first request reports its error, while the still-pending second delete request is allowed to make a new attempt.
  • Sharing the first result would instead return the leader's transient failure to the waiter without attempting its independently requested delete.

The new deterministic waiting_delete_retries_after_leader_failure_recovery test blocks the leader, queues a waiter, forces rollback, and verifies that the waiter retries only after the leader finishes. The driver is called twice sequentially, never concurrently.

The architecture note now states that gates are process-local, serialize attempts rather than share results, and rely on persisted resource-version checks as the cross-replica safety boundary.

@pimlock
pimlock requested a review from elezar July 17, 2026 19:01
@pimlock pimlock self-assigned this Jul 17, 2026
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

gator-agent

Re-check After Author Update

Thanks @pimlock. I re-evaluated head 335961d85d62374664240225bbe8cbea831434bc after your July 17 follow-up about stable identity, serialized retries, and the process-local gate scope.

What I checked: the public gRPC delete boundary, the pre-spawn identity binding, per-ID gate and global-lock acquisition, queued/canceled request behavior, failure recovery, watcher/reconciliation CAS fencing, the new deterministic tests, architecture docs, and the E2E polling helper.

Resolved from the prior gator review:

  • The compute runtime now binds the request to a stable ID before spawning, so scheduling delay inside that layer cannot retarget a reused name.
  • Your serialized-attempt explanation is sound: an active independent waiter may retry after a failed leader restores a deletable state; shared-result single-flight is not required.
  • The architecture doc now accurately states the process-local, serialized-attempt, restart, and HA limitations.
  • The lock-order and RAII concerns @elezar raised remain satisfied by SandboxDeleteGuard and the explicit delete-gate → global-lock path.

Blocking findings:

  • The public handler still resolves the name twice across layers. handle_delete_sandbox_inner first resolves the name for telemetry (crates/openshell-server/src/grpc/sandbox.rs:508), then ComputeRuntime::delete_sandbox resolves it again (crates/openshell-server/src/compute/mod.rs:688). If the original sandbox disappears and another sandbox reuses the name between those awaits, this request can delete the replacement while ending the original sandbox's telemetry session. Resolve once at the handler boundary and pass the stable target through, or return the actual deleted target ID for telemetry. Please add handler-level name-reuse coverage.
  • A canceled request can remain armed while waiting on the delete gate. The owned worker is spawned before it acquires the per-ID gate or persists Deleting (crates/openshell-server/src/compute/mod.rs:699-715). If a queued client cancels, its detached worker remains queued; after a leader fails and restores Ready, that canceled request can later issue a destructive retry. Acquire the per-ID gate (and the initial global guard) in the request future before spawning the cancellation-resistant worker, and add a queued-cancellation test proving no second driver call occurs.

Non-blocking follow-ups:

  • Distinguish CAS conflicts from transient recovery-write failures; a non-conflict write failure followed by a successful fetch of the unchanged row can currently leave a live sandbox permanently Deleting (compute/mod.rs:1018-1065).
  • Bound each sandbox list command execution inside the E2E deadline; cmd.output().await can otherwise outlive the advertised timeout indefinitely (e2e/rust/tests/sandbox_lifecycle.rs:47-83).

Docs: architecture coverage is appropriate; this remains an internal correctness fix, so Fern docs are not required.

Checks: Branch Checks and Helm Lint are green for this head. Required E2E is still pending, but author changes are required before pipeline results can advance gator.

Disposition: the prior findings are resolved as described above, but the two races remain blocking. Next state: gator:in-review.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

🏗️ build-from-issue-agent

Review follow-up

Addressed all findings from the latest gator review in ab966edbd868a0a5fd04adbb7c8c67a0580f8f5d:

  • ComputeRuntime::delete_sandbox now returns the stable sandbox ID it actually handled. The gRPC handler no longer performs its own preliminary name lookup, so telemetry cannot be ended for one sandbox while compute deletes a replacement with the same name. The handler-level name-reuse test holds the initial global guard, replaces the row after compute binds the original ID, and verifies that the replacement remains while telemetry receives the original ID.
  • The request future now resolves the name and acquires the per-ID gate plus initial global guard before spawning owned work. Cancellation while queued drops the waiting future and its gate reference; no detached worker exists yet. The new deterministic test cancels a follower behind a failing leader and verifies that recovery restores Ready with exactly one driver call.
  • Delete recovery distinguishes CAS conflicts from other persistence failures. Conflicts remain fenced as concurrent writes; non-conflict failures retry up to the existing bounded limit only while the exact owned Deleting resource version remains current.
  • Each E2E sandbox list subprocess is wrapped in the same absolute polling deadline, so a hung command cannot outlive the advertised timeout.

The PR description and architecture note now document the cancellation commitment point. mise run pre-commit passes, including 999 server tests; the focused compute suite passes 64 tests, and the modified E2E target compile-checks with --features e2e.

@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

gator-agent

Re-check After Author Update

Thanks @pimlock. I re-evaluated head ab966edbd868a0a5fd04adbb7c8c67a0580f8f5d after your July 17 follow-up about telemetry identity, queued cancellation, recovery retries, and E2E deadlines.

What I checked: the complete base-to-head diff, handler/compute identity flow, the delete gate and global-lock commitment point, driver deletion behavior, watcher removal and name reuse, recovery CAS handling, deterministic tests, architecture docs, and E2E polling.

Resolved from the prior gator review:

  • The handler no longer performs a separate name lookup; telemetry ends the stable ID returned by compute.
  • A queued cancellation now drops before any detached worker exists, while cancellation after both locks are acquired leaves the committed worker running.
  • Non-conflict recovery writes retry only while the exact owned Deleting version remains current.
  • Each E2E list subprocess is bounded, and the harness kills the child on timeout.

Blocking finding:

  • Deletion is not stable-ID-bound end to end. The gateway releases its global guard before awaiting the driver (crates/openshell-server/src/compute/mod.rs:774), so a deleted watcher event can remove the old row and free its name while the driver request is pending. Several built-in drivers can then retarget the old request to a replacement that reused that name:
    • Kubernetes ignores sandbox_id and deletes by name (crates/openshell-driver-kubernetes/src/grpc.rs:118-126).
    • Podman warns on an ID mismatch but still stops/removes by name (crates/openshell-driver-podman/src/driver.rs:629-668).
    • VM falls back to a name match when the requested ID is absent (crates/openshell-driver-vm/src/driver.rs:1040-1048).

Please make the stable ID authoritative throughout driver deletion. Kubernetes should verify the ID label and use an object-identity/UID precondition; container drivers should reject mismatches and remove an immutable ID-matching target; VM must not fall back to name when an ID was supplied. Add deterministic coverage where watcher removal and name reuse occur after the driver call begins but before it resolves its backend target.

Non-blocking follow-ups:

  • Avoid starting another E2E list command with an already-expired absolute deadline; return the last completed observation instead of panicking.
  • Clarify that watcher deleted events are authoritative while request/reconciliation cleanup is resource-version-bound.
  • Add deterministic coverage for a transient non-conflict recovery write failure with the owned version unchanged.

Docs: no Fern update is required; this remains internal lifecycle correctness. The architecture doc is the right surface with the clarification above.

Tests: test:e2e remains appropriate. Once the blocking change is ready, test:e2e-kubernetes should also run because this changes watcher/reconciliation behavior and the remaining identity race includes Kubernetes. GPU E2E is not needed.

Disposition: the prior findings are resolved, but the end-to-end stable-ID race remains blocking.

Head SHA: ab966edbd868a0a5fd04adbb7c8c67a0580f8f5d
Next state: gator:in-review

@pimlock

pimlock commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Response to the stable-ID finding

I agree with the desired end state: when sandbox_id is supplied, it should be authoritative and a driver must not mutate a different resource that merely has the same name. I do not think that broader contract gap should block this PR, though.

The ID/name pairing predates this change. GetSandboxRequest, StopSandboxRequest, and DeleteSandboxRequest have all carried both the stable gateway ID and runtime name since the compute-driver API was introduced. Enforcement is already inconsistent across drivers and operations: read paths generally check the returned ID, while some stop/delete paths still target by name. This PR did not add that identity model or the fallback behavior.

The normal single-gateway delete path in this PR cannot produce the retargeting sequence by itself:

  1. If the current driver call has not yet selected or deleted A, its watcher cannot report A as disappeared because of that call.
  2. Once the current driver call has selected/deleted A, its backend target is already bound; delaying the response does not make that same call select a later B.

For the stale {id: A, name: foo} request to reach a replacement {id: B, name: foo} before resolving its backend target, A must disappear independently—for example through an external actor, another gateway, restart/replayed work, or a previously delayed operation/event. Those are pre-existing reconciliation and multi-writer cases outside the single-gateway scope of #2326.

The old global lock masked this limitation by preventing the gateway from applying watcher removal and freeing the name during any driver call. This PR intentionally allows watcher processing to continue so an unrelated sandbox event cannot be head-of-line blocked. Making the watcher acquire each sandbox delete gate would recreate that head-of-line blocking because the watcher stream is processed sequentially.

Fixing only DeleteSandbox here would also leave the same contract inconsistent for StopSandbox and potentially other identifier-bearing operations. I opened #2347 to define sandbox_id as authoritative and audit GetSandbox, StopSandbox, and DeleteSandbox across Docker, Kubernetes, Podman, and VM, including mismatch/name-reuse tests and conformance coverage.

Given the independent-disappearance prerequisite and the accepted single-gateway scope, I propose treating this as the focused follow-up in #2347 rather than expanding #2340 into a cross-driver lifecycle-contract change.

@pimlock pimlock added gator:approval-needed Gator completed review; maintainer approval needed and removed gator:in-review Gator is reviewing or awaiting PR review feedback labels Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gator:approval-needed Gator completed review; maintainer approval needed test:e2e Requires end-to-end coverage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(server): prevent unrelated sandbox deletes from blocking deletion events

2 participants